{
  "cells": [
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "rZBo8gmrkN1U"
      },
      "source": [
        "## **2.环境配置**\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "A-0co4L3GlZr"
      },
      "source": [
        "请确保安装了以下依赖，否则无法运行。同时，需要安装 paddlemix/external_ops 下的自定义OP, **python setup.py install**。如果安装后仍然找不到算子，需要额外设置PYTHONPATH\n",
        "(默认开启flash_attn)使用flash_attn 要求A100/A800显卡或者H20显卡"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "BJj5pEqukUpP"
      },
      "outputs": [],
      "source": [
        "python >= 3.10\n",
        "paddlepaddle-gpu 要求版本develop\n",
        "paddlenlp == 3.0.0b2\n",
        "# 安装示例\n",
        "python -m pip install paddlepaddle-gpu==0.0.0.post118 -f https://www.paddlepaddle.org.cn/whl/linux/gpu/develop.html"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "4sSOGLuSkZB3"
      },
      "source": [
        "## **3.模型微调**\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "oGtDDlkELobN"
      },
      "source": [
        "## **3.1 微调数据准备**"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "ZriDFVW8KzXy"
      },
      "source": [
        "SFT数据集选择6个公开的数据集，包括dvqa、chartqa、ai2d、docvqa、geoqa+、synthdog_en，详见paddlemix/examples/qwen2_vl/configs/baseline_6data_330k.json\n",
        "\n",
        "下载链接为："
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "YenjyHeFlkeW"
      },
      "outputs": [],
      "source": [
        "wget https://paddlenlp.bj.bcebos.com/datasets/paddlemix/playground.tar # 50G\n",
        "wget https://paddlenlp.bj.bcebos.com/datasets/paddlemix/playground/opensource_json.tar"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "qVQZ3XFLLZFH"
      },
      "source": [
        "opensource_json.tar需下载解压在playground/目录下，opensource_json 里是数据标注的json格式文件。"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "J6eZze-rN8ar"
      },
      "outputs": [],
      "source": [
        "class LazySupervisedDataset(Dataset):\n",
        "    \"\"\"Dataset for supervised fine-tuning.\"\"\"\n",
        "\n",
        "    def __init__(\n",
        "        self,\n",
        "        template,\n",
        "        meta,\n",
        "        tokenizer,\n",
        "        ds_name,\n",
        "        processor,\n",
        "        max_image_size=512,\n",
        "        max_seq_length=8192,\n",
        "        repeat_time=1,\n",
        "        normalize_type=\"imagenet\",\n",
        "        random_seed=0,\n",
        "    ):\n",
        "        super(LazySupervisedDataset, self).__init__()\n",
        "        self.template = template\n",
        "\n",
        "        self.processor = processor\n",
        "        self.ds_name = ds_name\n",
        "        self.tokenizer = tokenizer\n",
        "        self.max_image_size = max_image_size\n",
        "        self.max_seq_length = max_seq_length\n",
        "        logger.info(\"Formatting inputs...Skip in lazy mode\")\n",
        "        if \"annotation\" in meta:\n",
        "            meta_anns = meta[\"annotation\"]\n",
        "        elif \"file_name\" in meta:\n",
        "            meta_anns = meta[\"file_name\"]\n",
        "        else:\n",
        "            raise ValueError(\"No annotation found in the meta file.\")\n",
        "\n",
        "        with open(meta_anns, \"r\") as f:  # qwen2_vl 读的是json\n",
        "            self.raw_data = json.load(f)\n",
        "            if repeat_time < 1:\n",
        "                # If repeat_time is less than 1, select a portion of the data\n",
        "                self.raw_data = self.raw_data[: int(len(self.raw_data) * repeat_time)]\n",
        "            if repeat_time > 1:\n",
        "                assert isinstance(repeat_time, int)\n",
        "                # Repeat the list if repeat_time is greater than 1\n",
        "                self.raw_data = self.raw_data * repeat_time\n",
        "\n",
        "        self.rng = np.random.default_rng(seed=random_seed)\n",
        "        self.rng.shuffle(self.raw_data)\n",
        "\n",
        "        self.cached_data_dict = {}\n",
        "        self.normalize_type = normalize_type\n",
        "\n",
        "    def __len__(self):\n",
        "        return len(self.raw_data)\n",
        "\n",
        "    def _preprocess_image(self, image):\n",
        "        r\"\"\"\n",
        "        Pre-processes a single image.\n",
        "        \"\"\"\n",
        "        image_resolution = self.max_image_size\n",
        "        if max(image.width, image.height) > image_resolution:\n",
        "            resize_factor = image_resolution / max(image.width, image.height)\n",
        "            width, height = int(image.width * resize_factor), int(image.height * resize_factor)\n",
        "            image = image.resize((width, height), resample=Image.NEAREST)\n",
        "\n",
        "        if image.mode != \"RGB\":\n",
        "            image = image.convert(\"RGB\")\n",
        "\n",
        "        if min(image.width, image.height) < 28:\n",
        "            width, height = max(image.width, 28), max(image.height, 28)\n",
        "            image = image.resize((width, height), resample=Image.NEAREST)\n",
        "\n",
        "        if image.width / image.height > 200:\n",
        "            width, height = image.height * 180, image.height\n",
        "            image = image.resize((width, height), resample=Image.NEAREST)\n",
        "\n",
        "        if image.height / image.width > 200:\n",
        "            width, height = image.width, image.width * 180\n",
        "            image = image.resize((width, height), resample=Image.NEAREST)\n",
        "\n",
        "        return image\n",
        "\n",
        "    def load_image(self, image_path):\n",
        "        image = Image.open(image_path).convert(\"RGB\")\n",
        "        return self._preprocess_image(image)\n",
        "\n",
        "    def get_image_path(self, image_path):\n",
        "        # image_path = os.path.join(self.root, image_path)\n",
        "        return image_path\n",
        "\n",
        "    def get_transform(self):\n",
        "        return self.processor.image_processor\n",
        "\n",
        "    def multi_modal_get_item(self, data_item):\n",
        "        # Build transformation function\n",
        "        transform = self.get_transform()\n",
        "\n",
        "        # Ensure the first conversation contains an image placeholder\n",
        "        if \"<image>\" not in data_item[\"messages\"][0][\"content\"]:\n",
        "            data_item[\"messages\"][0][\"content\"] = \"<image>\\n\" + data_item[\"messages\"][0][\"content\"]\n",
        "\n",
        "        # Merge the image path\n",
        "        image_path = self.get_image_path(data_item[\"images\"][0])  # TODO: now only single image\n",
        "        image = self.load_image(image_path)\n",
        "        image_data_dict = transform(image)\n",
        "\n",
        "        messages = data_item[\"messages\"]\n",
        "\n",
        "        input_ids, labels = _encode_supervised_example(\n",
        "            messages=messages,\n",
        "            system=\"\",\n",
        "            tools=\"\",\n",
        "            images=[image_path],\n",
        "            videos=[],\n",
        "            template=self.template,\n",
        "            tokenizer=self.tokenizer,\n",
        "            processor=self.processor,\n",
        "            cutoff_len=self.max_seq_length,\n",
        "            train_on_prompt=False,\n",
        "            mask_history=False,\n",
        "        )\n",
        "        attention_mask = [1] * len(input_ids)\n",
        "\n",
        "        # Create the final return dictionary\n",
        "        ret = dict(\n",
        "            input_ids=input_ids,\n",
        "            labels=labels,\n",
        "            attention_mask=attention_mask,\n",
        "            pixel_values=image_data_dict[\"pixel_values\"],\n",
        "            image_grid_thw=image_data_dict[\"image_grid_thw\"][0],\n",
        "        )\n",
        "        return ret\n",
        "\n",
        "    def pure_text_get_item(self, data_item):\n",
        "        # Build transformation function\n",
        "        transform = self.get_transform()\n",
        "\n",
        "        # Create a blank white image\n",
        "        image = Image.new(\"RGB\", (224, 224), (255, 255, 255))\n",
        "        image_data_dict = transform(image)\n",
        "\n",
        "        messages = data_item[\"messages\"]\n",
        "\n",
        "        input_ids, labels = _encode_supervised_example(\n",
        "            messages=messages,\n",
        "            system=\"\",\n",
        "            tools=\"\",\n",
        "            images=[],\n",
        "            videos=[],\n",
        "            template=self.template,\n",
        "            tokenizer=self.tokenizer,\n",
        "            processor=self.processor,\n",
        "            cutoff_len=self.max_seq_length,\n",
        "            train_on_prompt=False,\n",
        "            mask_history=False,\n",
        "        )\n",
        "        attention_mask = [1] * len(input_ids)\n",
        "\n",
        "        # Create the final return dictionary\n",
        "        ret = dict(\n",
        "            input_ids=input_ids,\n",
        "            labels=labels,\n",
        "            attention_mask=attention_mask,\n",
        "            pixel_values=image_data_dict[\"pixel_values\"],\n",
        "            image_grid_thw=image_data_dict[\"image_grid_thw\"][0],\n",
        "        )\n",
        "        return ret\n",
        "\n",
        "    def __getitem__(self, i) -> Dict[str, paddle.Tensor]:\n",
        "        i = i % len(self.raw_data)\n",
        "        while True:\n",
        "            try:\n",
        "                data_item = self.raw_data[i]\n",
        "                if \"images\" in data_item and len(data_item[\"images\"]) != 0:\n",
        "                    # if type(data_item['images']) == list:\n",
        "                    #     ret = self.multi_modal_multi_image_get_item(data_item)\n",
        "                    # else:\n",
        "                    #     ret = self.multi_modal_get_item(data_item)\n",
        "                    ret = self.multi_modal_get_item(data_item)  # TODO: 暂时都是单图\n",
        "                else:\n",
        "                    ret = self.pure_text_get_item(data_item)  # TODO: 纯文\n",
        "                break\n",
        "            except Exception as e:\n",
        "                print(e, self.ds_name, flush=True)\n",
        "                if not isinstance(e, UnidentifiedImageError):\n",
        "                    traceback.print_exc()\n",
        "                data_item = self.raw_data[i]\n",
        "                if \"images\" in data_item:\n",
        "                    if type(data_item[\"images\"]) == list:\n",
        "                        images = [item for item in data_item[\"images\"]]\n",
        "                        print(f\"Failed to load image: {images}, the dataset is: {self.ds_name}\")\n",
        "                    else:\n",
        "                        data_path = data_item[\"images\"]\n",
        "                        print(f\"Failed to load image: {data_path}, the dataset is: {self.ds_name}\")\n",
        "                elif \"video\" in data_item:\n",
        "                    data_path = data_item[\"video\"]\n",
        "                    print(f\"Failed to load video: {data_path}, the dataset is: {self.ds_name}\")\n",
        "                i = random.randint(0, len(self.raw_data) - 1)\n",
        "        return ret\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "FeO0Kf-cLwg5"
      },
      "source": [
        "## **3.2 微调命令**"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "ddfBOnNHL95u"
      },
      "source": [
        "注意：此微调训练为全参数微调，冻结视觉编码器而放开LLM训练，2B模型微调训练的显存大小约为30G，7B模型微调训练的显存大小约为75G。"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "jSHZB3KfLdXC"
      },
      "outputs": [],
      "source": [
        "# 2B\n",
        "sh paddlemix/examples/qwen2_vl/shell/baseline_2b_bs32_1e8.sh\n",
        "\n",
        "# 7B\n",
        "sh paddlemix/examples/qwen2_vl/shell/baseline_7b_bs32_1e8.sh"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "06yKKQzMOAXX"
      },
      "outputs": [],
      "source": [
        "def _freeze_params(module):\n",
        "        for param in module.parameters():\n",
        "            param.stop_gradient = not False\n",
        "\n",
        "    if model_args.freeze_vit:\n",
        "        _freeze_params(model.visual)\n",
        "\n",
        "    if model_args.freeze_llm:\n",
        "        model.model = model.model.eval()\n",
        "        model.lm_head = model.lm_head.eval()\n",
        "        _freeze_params(model.model)\n",
        "        _freeze_params(model.lm_head)\n"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "vqYDCikMMDV_"
      },
      "source": [
        "## **3.2 微调后使用**"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "orycnj-KMR1w"
      },
      "source": [
        "同按步骤3中的模型推理预测，只需将paddlemix/examples/qwen2_vl/single_image_infer.py中的MODEL_NAME参数修改为微调后的模型路径即可。"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "HYZOggySMPKT"
      },
      "outputs": [],
      "source": [
        "python paddlemix/examples/qwen2_vl/single_image_infer.py"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "bnP2gVtYllKm"
      },
      "source": [
        "## **4.模型推理**"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "XjU4X91wl1M6"
      },
      "source": [
        "## **4.1 单图预测**"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "sPhzrM0sl9hq"
      },
      "outputs": [],
      "source": [
        "from paddlemix.models.qwen2_vl import MIXQwen2Tokenizer\n",
        "from paddlemix.models.qwen2_vl.modeling_qwen2_vl import Qwen2VLForConditionalGeneration\n",
        "from paddlemix.processors.qwen2_vl_processing import (\n",
        "    Qwen2VLImageProcessor,\n",
        "    Qwen2VLProcessor,\n",
        "    process_vision_info,\n",
        ")\n",
        "\n",
        "MODEL_NAME = \"Qwen/Qwen2-VL-2B-Instruct\"\n",
        "model = Qwen2VLForConditionalGeneration.from_pretrained(MODEL_NAME, dtype=\"bfloat16\")\n",
        "\n",
        "image_processor = Qwen2VLImageProcessor()\n",
        "tokenizer = MIXQwen2Tokenizer.from_pretrained(MODEL_NAME)\n",
        "processor = Qwen2VLProcessor(image_processor, tokenizer)\n",
        "\n",
        "# min_pixels = 256*28*28 # 200704\n",
        "# max_pixels = 1280*28*28 # 1003520\n",
        "# processor = Qwen2VLProcessor(image_processor, tokenizer, min_pixels=min_pixels, max_pixels=max_pixels)\n",
        "\n",
        "\n",
        "messages = [\n",
        "    {\n",
        "        \"role\": \"user\",\n",
        "        \"content\": [\n",
        "            {\n",
        "                \"type\": \"image\",\n",
        "                \"image\": \"paddlemix/demo_images/examples_image1.jpg\",\n",
        "            },\n",
        "            {\"type\": \"text\", \"text\": \"Describe this image.\"},\n",
        "        ],\n",
        "    }\n",
        "]\n",
        "\n",
        "# Preparation for inference\n",
        "image_inputs, video_inputs = process_vision_info(messages)\n",
        "\n",
        "question = \"Describe this image.\"\n",
        "image_pad_token = \"<|vision_start|><|image_pad|><|vision_end|>\"\n",
        "text = f\"<|im_start|>system\\nYou are a helpful assistant.<|im_end|>\\n<|im_start|>user\\n{image_pad_token}{question}<|im_end|>\\n<|im_start|>assistant\\n\"\n",
        "\n",
        "inputs = processor(\n",
        "    text=[text],\n",
        "    images=image_inputs,\n",
        "    videos=video_inputs,\n",
        "    padding=True,\n",
        "    return_tensors=\"pd\",\n",
        ")\n",
        "\n",
        "# Inference: Generation of the output\n",
        "generated_ids = model.generate(**inputs, max_new_tokens=128)  # already trimmed in paddle\n",
        "output_text = processor.batch_decode(generated_ids[0], skip_special_tokens=True, clean_up_tokenization_spaces=False)\n",
        "print(\"output_text:\\n\", output_text[0])"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "uN1HSbhKMhBo"
      },
      "source": [
        "## **脚本命令**"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "hb20WGN_Mm_s"
      },
      "outputs": [],
      "source": [
        "python paddlemix/examples/qwen2_vl/single_image_infer.py"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "XpbnaxLdmBcE"
      },
      "source": [
        "## **4.2 多图预测**"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "ikWX6CAmmIyn"
      },
      "outputs": [],
      "source": [
        "from paddlemix.models.qwen2_vl import MIXQwen2Tokenizer\n",
        "from paddlemix.models.qwen2_vl.modeling_qwen2_vl import Qwen2VLForConditionalGeneration\n",
        "from paddlemix.processors.qwen2_vl_processing import (\n",
        "    Qwen2VLImageProcessor,\n",
        "    Qwen2VLProcessor,\n",
        "    process_vision_info,\n",
        ")\n",
        "\n",
        "MODEL_NAME = \"Qwen/Qwen2-VL-2B-Instruct\"\n",
        "model = Qwen2VLForConditionalGeneration.from_pretrained(MODEL_NAME, dtype=\"bfloat16\")\n",
        "\n",
        "image_processor = Qwen2VLImageProcessor()\n",
        "tokenizer = MIXQwen2Tokenizer.from_pretrained(MODEL_NAME)\n",
        "processor = Qwen2VLProcessor(image_processor, tokenizer)\n",
        "\n",
        "# min_pixels = 256*28*28 # 200704\n",
        "# max_pixels = 1280*28*28 # 1003520\n",
        "# processor = Qwen2VLProcessor(image_processor, tokenizer, min_pixels=min_pixels, max_pixels=max_pixels)\n",
        "\n",
        "\n",
        "messages = [\n",
        "    {\n",
        "        \"role\": \"user\",\n",
        "        \"content\": [\n",
        "            {\"type\": \"image\", \"image\": \"paddlemix/demo_images/examples_image1.jpg\"},\n",
        "            {\"type\": \"image\", \"image\": \"paddlemix/demo_images/examples_image2.jpg\"},\n",
        "            {\"type\": \"text\", \"text\": \"Identify the similarities between these images.\"},\n",
        "        ],\n",
        "    }\n",
        "]\n",
        "\n",
        "# Preparation for inference\n",
        "image_inputs, video_inputs = process_vision_info(messages)\n",
        "\n",
        "question = \"Identify the similarities between these images.\"\n",
        "image_pad_tokens = \"<|vision_start|><|image_pad|><|vision_end|>\" * len(image_inputs)\n",
        "text = f\"<|im_start|>system\\nYou are a helpful assistant.<|im_end|>\\n<|im_start|>user\\n{image_pad_tokens}{question}<|im_end|>\\n<|im_start|>assistant\\n\"\n",
        "\n",
        "inputs = processor(\n",
        "    text=[text],\n",
        "    images=image_inputs,\n",
        "    videos=video_inputs,\n",
        "    padding=True,\n",
        "    return_tensors=\"pd\",\n",
        ")\n",
        "\n",
        "# Inference: Generation of the output\n",
        "generated_ids = model.generate(**inputs, max_new_tokens=128)  # already trimmed in paddle\n",
        "output_text = processor.batch_decode(generated_ids[0], skip_special_tokens=True, clean_up_tokenization_spaces=False)\n",
        "print(\"output_text:\\n\", output_text[0])"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "vX_TRc2gMsGH"
      },
      "source": [
        "## **脚本命令**"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "1ymdFuY5MtnI"
      },
      "outputs": [],
      "source": [
        "python paddlemix/examples/qwen2_vl/multi_image_infer.py"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "D4GlS50QmJhu"
      },
      "source": [
        "## **4.3 视频预测**"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "f5k7V874mPqi"
      },
      "outputs": [],
      "source": [
        "from paddlemix.models.qwen2_vl import MIXQwen2Tokenizer\n",
        "from paddlemix.models.qwen2_vl.modeling_qwen2_vl import Qwen2VLForConditionalGeneration\n",
        "from paddlemix.processors.qwen2_vl_processing import (\n",
        "    Qwen2VLImageProcessor,\n",
        "    Qwen2VLProcessor,\n",
        "    process_vision_info,\n",
        ")\n",
        "\n",
        "MODEL_NAME = \"Qwen/Qwen2-VL-7B-Instruct\"\n",
        "model = Qwen2VLForConditionalGeneration.from_pretrained(MODEL_NAME, dtype=\"bfloat16\")\n",
        "\n",
        "image_processor = Qwen2VLImageProcessor()\n",
        "tokenizer = MIXQwen2Tokenizer.from_pretrained(MODEL_NAME)\n",
        "min_pixels = 256 * 28 * 28  # 200704\n",
        "max_pixels = 1280 * 28 * 28  # 1003520\n",
        "processor = Qwen2VLProcessor(image_processor, tokenizer, min_pixels=min_pixels, max_pixels=max_pixels)\n",
        "\n",
        "# Messages containing a video and a text query\n",
        "messages = [\n",
        "    {\n",
        "        \"role\": \"user\",\n",
        "        \"content\": [\n",
        "            {\n",
        "                \"type\": \"video\",\n",
        "                \"video\": \"paddlemix/demo_images/red-panda.mp4\",\n",
        "                \"max_pixels\": 360 * 420,\n",
        "                \"fps\": 1.0,\n",
        "            },\n",
        "            {\"type\": \"text\", \"text\": \"Describe this video.\"},\n",
        "        ],\n",
        "    }\n",
        "]\n",
        "\n",
        "image_inputs, video_inputs = process_vision_info(messages)\n",
        "question = \"Describe this video.\"\n",
        "video_pad_token = \"<|vision_start|><|video_pad|><|vision_end|>\"\n",
        "text = f\"<|im_start|>system\\nYou are a helpful assistant.<|im_end|>\\n<|im_start|>user\\n{video_pad_token}{question}<|im_end|>\\n<|im_start|>assistant\\n\"\n",
        "\n",
        "inputs = processor(\n",
        "    text=[text],\n",
        "    images=image_inputs,\n",
        "    videos=video_inputs,\n",
        "    padding=True,\n",
        "    return_tensors=\"pd\",\n",
        ")\n",
        "# Inference: Generation of the output\n",
        "generated_ids = model.generate(**inputs, max_new_tokens=128)  # already trimmed in paddle\n",
        "# print(\"generated_ids:\\n\", generated_ids)\n",
        "output_text = processor.batch_decode(generated_ids[0], skip_special_tokens=True, clean_up_tokenization_spaces=False)\n",
        "print(\"output_text:\\n\", output_text[0])"
      ]
    },
    {
      "cell_type": "markdown",
      "metadata": {
        "id": "iWg6LMJvMzN3"
      },
      "source": [
        "## **脚本命令**"
      ]
    },
    {
      "cell_type": "code",
      "execution_count": null,
      "metadata": {
        "id": "tZQ7dNiHM0Wi"
      },
      "outputs": [],
      "source": [
        "python paddlemix/examples/qwen2_vl/video_infer.py"
      ]
    }
  ],
  "metadata": {
    "colab": {
      "provenance": []
    },
    "kernelspec": {
      "display_name": "Python 3",
      "name": "python3"
    },
    "language_info": {
      "name": "python"
    }
  },
  "nbformat": 4,
  "nbformat_minor": 0
}
